import json
import os
from datetime import datetime
import sys

sys.path.append("/home/sivan/0_0/projects/gitee/Small_Tools/utils")
import re
from tkinter import *
from tkinter import ttk
import threading
from sys import getsizeof as getsize
import subprocess
import platform
from enum import Enum

"""
版本说明:

V1.0: 2021-03-13 19:14
初始版本为命令行程序，功能：从文本文件搜索目标字符串。
  参数：
    目标字符串 target：可以是以空格分隔的多个字符串，多个字符串搜索时，文件同时包含这些字符串才认为搜索成功。
    路径 path：可以从此目录及子目录中搜索。
    忽略大小写 ignore uppercase and lowercase：可以忽略大小写。
    进度显示精度 float：设置进度显示时小数的位数。

V2.0: 2021-03-14 23:26
新增搜索文件的类型: docx,doc,xlsx,xls,pptx,ppt,pdf,xmind

V3.0: 2021-06-23 23:38
1. 命令行程序改为UI程序。
2. 暂时去掉支持搜索文件的类型: docx,doc,xlsx,xls,pptx,ppt,pdf,xmind ，因为不支持 linux 。

V3.1: 2021-06-25 20:35
配置文件校验问题修复。

V4.0: 2021-06-29 01:02
如果指定了文本编辑器程序路径，则点击搜索结果可以使用该文本编辑器打开。
如果没有指定文本编辑器程序路径，则依然用文本显示搜索结果。

V5.0: 2023-01-10 23:42
性能优化：已读取过的文本保存到内存，以加快下次搜索的速度。

V6.0: 2023-01-11 12:35
新增全词匹配功能：勾选此选项后，被搜索的字符串必须为一个单词，而不是子字符串。

V7.0: 2023-02-20 12:27
1. 新增保存历史搜索路径功能：被搜索过的路径保存到配置文件，点击路径框时按搜索时间倒序显示。
2. 新增“文件名/路径限制”功能：被搜索的路径被包含时才认为匹配成功。
3. 新增“清除缓存”功能：可以清除被保存到内存的文件。

V7.1: 2024-01-24 01:32
1. 解决notepad执行程序和搜索文件路径包含空格的问题，且兼容linux。
2. 文件名和路径限制时忽略大小写。

V8.0: 2024-01-24 02:41
动态筛选历史记录
"""


def readTxtFile(path):
    encoding = ['utf-8', 'gbk']
    for i in encoding:
        try:
            with open(path, encoding=i) as f:
                return f.read()
        except UnicodeDecodeError as e:
            pass
    raise Exception("文件编码不在 " + str(encoding) + " 范围内")


def getSizeH(var):
    var = json.dumps(var)
    num = getsize(var)
    t = ["B", "KB", "MB", "GB"]
    k = 1024
    cnt = 0
    for i in t:
        if num < k:
            break
        cnt += 1
        num /= k
    return f"{round(num, 4)}{t[cnt]}"


class ListBox:
    def __init__(self, p_master, values, master, width):
        self.p_master = p_master
        self.master = master
        self.width = width
        # self.show(values)
        self.top_window = None

    def show(self, values):
        x = self.master.winfo_rootx()
        y = self.master.winfo_rooty() + self.master.winfo_height()
        self.top_window = Toplevel(self.master)
        self.top_window.wm_overrideredirect(1)
        self.top_window.wm_geometry(f"+{x}+{y}")
        self.top_window.bind_all("<Button-1>", self.on_global_click)
        self.top_window.bind("<Configure>", self.on_change_geometry)
        self.list_box = Listbox(self.top_window, width=self.width)
        self.list_box.grid(row=0, column=0)
        v = []
        for i in values:
            v.append(values[i])
        v = sorted(v, reverse=True)
        k = []
        for i in v:
            for j in values:
                if i == values[j]:
                    k.append(j)
        for data in k:
            self.list_box.insert(END, data)
        self.list_box.bind("<ButtonRelease-1>", self.on_click_item)

    def on_change_geometry(self, event):
        x = self.master.winfo_rootx()
        y = self.master.winfo_rooty() + self.master.winfo_height()
        self.top_window.wm_geometry(f"+{x}+{y}")

    def on_global_click(self, event):
        if isinstance(event.widget, Listbox) or isinstance(event.widget, Scrollbar):
            pass
        else:
            self.hide()

    def hide(self):
        if self.top_window:
            self.top_window.destroy()
        self.top_window = None

    def on_click_item(self, event):
        self.p_master.path_old.set(self.list_box.get(self.list_box.curselection()))
        self.hide()

    def is_show(self):
        if self.top_window and self.top_window.state() == "normal":
            return True
        else:
            return False


class ComboBox:
    def __init__(self, p_master, master, values, width):
        self.p_master = p_master
        self.master = master
        self.values = values
        self.frame = Frame(self.master)
        self.entry = Entry(self.frame, textvariable=self.p_master.path_old, width=width)
        self.entry.grid()
        self.entry.bind("<ButtonRelease-1>", self.on_entry_click)
        self.entry.bind("<KeyRelease>", self.on_entry_change)
        self.list_box = ListBox(self.p_master, values, self.entry, width)

    def on_entry_change(self, event):
        keys = [i for i in event.widget.get().lower().split(" ") if i != '']
        keys_len = len(keys)
        result = {}
        for value in self.values:
            cnt = 0
            for key in keys:
                if key in value.lower():
                    cnt += 1
            if cnt == keys_len:
                result[value] = self.values[value]
        self.list_box.hide()
        self.list_box.show(result)

    def on_entry_click(self, event):
        if self.list_box.is_show():
            self.list_box.hide()
        else:
            self.list_box.show(self.values)

    def on_update_list(self):
        self.values[self.entry.get()] = datetime.now().timestamp()


class OST(Enum):
    windows = "windows"
    linux = "linux"


class Main:
    def __init__(self):
        self.config = "run.json"
        data = readTxtFile(self.config)
        try:
            self.conf = json.loads(data)
        except json.decoder.JSONDecodeError as e:
            print("run.json文件格式错误: " + e.args[0])
            return
        # 如果不是 windows 则暂时一律当作 linux 处理
        self.ost = OST.windows if platform.system() == "Windows" else OST.linux
        self.__check()
        self.cache_file = {}
        root = Tk()
        root.title("文件字符串搜索")
        root.minsize(500, 200)

        frame0 = Frame(root)
        frame0.grid(row=1, columnspan=2, sticky=W)
        Label(frame0, text='搜索内容: ').grid(row=0, column=0, sticky=E)
        cont_old = StringVar(value=self.target)
        self.cont_entry = Entry(frame0, textvariable=cont_old, width=100)
        self.cont_entry.grid(row=0, column=1, sticky=W)
        Label(frame0, text='文件名/路径限制: ').grid(row=0, column=2, sticky=E)
        cont_file_filter = StringVar(value=self.file_filter)
        self.file_filter_entry = Entry(frame0, textvariable=cont_file_filter, width=30)
        self.file_filter_entry.grid(row=0, column=3, sticky=W)
        self.path_old = StringVar(value=self.conf['path'])
        Label(root, text='搜索路径: ').grid(row=2, column=0, sticky=E)
        # self.path_entry = Entry(root, textvariable = path_old, width = 150)
        # self.path_entry.grid(row = 1, column = 1, sticky=W)
        self.path_search_history = self.conf["path_search_history"]
        self.path_entry = ComboBox(self, root, self.path_search_history, 150)
        self.path_entry.frame.grid(row=2, column=1, sticky=W)
        frame1 = Frame(root)
        frame1.grid(row=3, columnspan=2, sticky=W)
        Label(frame1, text='忽略大小写: ').grid(row=1, column=0, sticky=E)
        self.ignore_check = BooleanVar(value=self.ignore)
        Checkbutton(frame1, '', variable=self.ignore_check, onvalue=1, offvalue=0).grid(row=1, column=1, sticky=W)
        self.whole_check = BooleanVar(value=self.whole)
        Label(frame1, text='全单词匹配: ').grid(row=1, column=2, sticky=E)
        Checkbutton(frame1, '', variable=self.whole_check, onvalue=1, offvalue=0).grid(row=1, column=3, sticky=W)
        width_old = IntVar(value=self.width)
        Label(frame1, text='进度显示精度: ').grid(row=1, column=4, sticky=E)
        self.float_entry = Entry(frame1, textvariable=width_old)
        self.float_entry.grid(row=1, column=5, sticky=W)
        Label(frame1, text='notepad: ').grid(row=1, column=6, sticky=W)
        self.npp_path_entry = Entry(frame1, textvariable=StringVar(value=self.conf['npp_path']), width=80)
        self.npp_path_entry.grid(row=1, column=7, sticky=W)
        self.frame3 = Frame(root)
        self.frame3.grid(row=4, columnspan=2, sticky=W)
        button1 = Button(self.frame3, text="开始搜索", command=self.begin_search)
        button1.grid(row=0, column=0, sticky=E)
        self.proc_end = StringVar()
        self.proc_end.set('')
        Label(self.frame3, textvariable=self.proc_end).grid(row=0, column=1, sticky=W)
        self.proc = StringVar()
        self.proc.set('')
        Label(self.frame3, textvariable=self.proc).grid(row=0, column=2, sticky=W)
        self.memery_count = StringVar()
        self.memery_count.set('')
        Label(self.frame3, textvariable=self.memery_count).grid(row=0, column=3, sticky=W)
        Label(self.frame3, textvariable="    ").grid(row=0, column=4, sticky=W)
        button_clear = Button(self.frame3, text="清除缓存", command=self.clear_cache)
        button_clear.grid(row=0, column=5, sticky=E)
        self.frame3 = Frame(root)
        columns = ("name",)
        headers = ("路径",)
        widthes = (1250,)
        self.path_view = ttk.Treeview(self.frame3, show="headings", columns=columns)
        self.path_view.bind('<ButtonRelease-1>', self.open_npp)
        for (column, header, width) in zip(columns, headers, widthes):
            self.path_view.column(column, width=width, anchor="w")
            self.path_view.heading(column, text=header, anchor="w")
        scrollbar_v_2 = ttk.Scrollbar(self.frame3, orient=VERTICAL, command=self.path_view.yview)
        self.path_view.configure(yscrollcommand=scrollbar_v_2.set)
        self.path_view.grid(row=0, column=0, sticky=NSEW)
        scrollbar_v_2.grid(row=0, column=1, sticky=NS)
        self.search_count = 0
        self.frame2 = Frame(root)
        self.text1 = Text(self.frame2, width=160)
        self.text1.grid(row=0, column=0, sticky=W)
        scrollbar_v = Scrollbar(self.frame2)
        scrollbar_v.grid(row=0, column=1, sticky='ns')
        scrollbar_v.config(command=self.text1.yview)
        self.text1.config(yscrollcommand=scrollbar_v.set)
        if os.path.exists(self.npp_path):
            self.frame3.grid(row=5, columnspan=2, sticky=W)
        else:
            self.frame2.grid(row=5, columnspan=2, sticky=W)

        root.mainloop()

    def open_npp(self, event):
        item = self.path_view.selection()
        if not item: return
        txt = self.path_view.item(item[0], 'text')
        path = '"' + self.npp_path + '" "' + txt + '"'
        if self.ost == OST.windows:
            subprocess.Popen(path)
        else:
            # linux 系统要加上 & ，表示以后台的方式打开，不然不能连续打开多个文件，如果要打开第二个文件需要先关闭第一个文件
            os.system(path + " &")

    def __check(self):
        true = True
        conf = self.conf
        try:
            self.text1.insert("end", "")
            hasT = True
        except:
            hasT = False
        if not os.path.exists(conf['path']):
            if hasT:
                self.text1.insert("end", "\n路径不存在: " + str(conf['path']) + "\n")
            else:
                print("路径不存在: " + str(conf['path']))
            true = False
        try:
            self.width = int(conf['float'])
        except Exception as e:
            self.width = 5
            if hasT:
                self.text1.insert("end", "\n小数位数错误: " + str(conf['float']) + "\n")
            else:
                print("小数位数错误: " + str(conf['float']))
            true = False
        self.target = str(conf['target']).strip()
        if 0 == len(self.target):
            if hasT:
                self.text1.insert("end", "\n搜索目标为空\n")
            else:
                print("搜索目标为空")
            true = False
        self.ignore = conf["ignore uppercase and lowercase"]
        self.whole = conf["whole"]
        self.npp_path = conf["npp_path"]
        self.file_filter = conf["file_filter"]
        fileTypes = conf["file types"]
        if 0 == len(fileTypes):
            if hasT:
                self.text1.insert("end", "\n文件类型为空\n")
            else:
                print("文件类型为空")
            true = False
        return true

    def begin_search(self):
        self.text1.delete(1.0, "end")
        cont = self.cont_entry.get()
        width = self.float_entry.get()
        ignore = self.ignore_check.get()
        self.path_entry.on_update_list()
        self.conf["target"] = cont
        self.conf["path"] = self.path_entry.entry.get()
        self.conf["float"] = width
        self.conf["ignore uppercase and lowercase"] = ignore
        self.conf["whole"] = self.whole_check.get()
        self.conf["npp_path"] = self.npp_path_entry.get()
        self.conf["path_search_history"] = self.path_entry.values
        self.conf["file_filter"] = self.file_filter_entry.get()
        status = self.__check()
        if not status: return
        with open(self.config, "w+") as f:
            f.write(json.dumps(self.conf, indent=4, ensure_ascii=False))
        # self.find_in_text(self.conf, int(width), cont, ignore)
        threading.Thread(target=self.find_in_text, args=(self.conf, int(width), cont, ignore)).start()

    def find_in_text(self, conf, width, target, ignore):
        for child in self.path_view.get_children():
            self.path_view.delete(child)
        begin = datetime.now()
        self.proc_end.set("")
        fileTypes = conf["file types"]
        if 0 == len(fileTypes):
            print("文件类型为空")
            return
        for fileType in range(len(fileTypes)):
            fileTypes[fileType] = fileTypes[fileType].lower()
        targets = [i for i in target.split(' ') if i != '']
        process = 0
        path_tmp = {}
        proc_tmp = []
        cur_lev = 0
        first = True
        conf_path = conf['path']
        if conf_path in self.cache_file:
            for filePath in self.cache_file[conf_path]:
                content = self.cache_file[conf_path][filePath]
                self.check_content(ignore, content, targets, filePath, begin)
        else:
            self.cache_file[conf_path] = {}
            for root, dirs, files in os.walk(conf_path):
                if root in path_tmp:
                    cur_lev = path_tmp[root] + 1
                    proc_tmp = proc_tmp[:cur_lev]
                    del path_tmp[root]
                for sub_dir in dirs:
                    path_tmp[root + "/" + sub_dir] = cur_lev
                tmp_len = len(dirs) + (1 if 0 != len(files) else 0)
                if 0 != tmp_len: proc_tmp.append(tmp_len)
                cur_proc_mul = 1
                for mul in range(cur_lev if 0 == tmp_len else cur_lev + 1):
                    cur_proc_mul /= proc_tmp[mul]
                if 0 == tmp_len and not first:
                    process += 1 / proc_tmp[cur_lev - 1] * cur_proc_mul
                    # String.output_proc("    *** " + str(round(process * 100, width)) + "% ***")
                    self.proc.set("    *** " + str(round(process * 100, width)) + "% ***")
                first = False
                len2 = len(files)
                for file in files:
                    process += cur_proc_mul * (1 / len2)
                    # String.output_proc("    *** " + str(round(process * 100, width)) + "% ***")
                    self.proc.set("    *** " + str(round(process * 100, width)) + "% ***")
                    filePath = root + "/" + file
                    """if file.endswith(".docx") and "docx" in fileTypes and not file.startswith("~$"):
                        try: content = readDocxFile(filePath)
                        except Exception as e: continue
                    elif file.endswith(".doc") and "doc" in fileTypes and not file.startswith("~$"):
                        try: content = readDocFile(filePath)
                        except Exception as e: continue
                    elif file.endswith(".xls") and "xls" in fileTypes and not file.startswith("~$"):
                        try: content = readXlsFile(filePath)
                        except Exception as e: continue
                    elif file.endswith(".xlsx") and "xlsx" in fileTypes and not file.startswith("~$"):
                        try: content = readXlsxFile(filePath)
                        except Exception as e: continue
                    elif file.endswith(".ppt") and "ppt" in fileTypes and not file.startswith("~$"):
                        try: content = readPptFile(filePath)
                        except Exception as e: continue
                    elif file.endswith(".pptx") and "pptx" in fileTypes and not file.startswith("~$"):
                        try: content = readPptxFile(filePath)
                        except Exception as e: continue
                    elif file.endswith(".pdf") and "pdf" in fileTypes and not file.startswith("~$"):
                        try: content = readPdfFile(filePath)
                        except Exception as e: continue
                    elif file.endswith(".xmind") and "xmind" in fileTypes and not file.startswith("~$"):
                        try: content = readXmindFile(filePath)
                        except Exception as e: continue
                    else:"""
                    try:
                        content = readTxtFile(filePath)
                    except Exception as e:
                        continue
                    self.cache_file[conf_path][filePath] = content
                    self.check_content(ignore, content, targets, filePath, begin)
        self.proc.set("    *** " + str(round(100, width)) + "% ***")
        self.memery_count.set(f"    内存占用: {getSizeH(self.cache_file)}")

    def check_content(self, ignore, content, targets, filePath, begin):
        # 文件名和路径限制: 限制框的内容全部被包含于路径时则继续
        file_arr = [i.lower() for i in self.file_filter_entry.get().split(' ') if i != '']
        file_filter_idx = 0
        for file_filter_item in file_arr:
            if file_filter_item in filePath.lower():
                file_filter_idx += 1
        if file_filter_idx != len(file_arr) and len(file_arr) != 0:
            return
        # 筛选出包含目标的文件
        count = 0
        if ignore: content = content.lower()
        if self.whole: content = re.findall(r"\w+", content)
        for t in targets:
            if ignore: t = t.lower()
            if t not in content: break
            count += 1
        if count == len(targets):
            # String.output_proc(root + "\\" + file, "\n")
            filePath = filePath.replace("\\", "/")
            self.text1.insert("end", filePath + "\n")
            self.path_view.insert('', self.search_count, values=filePath, text=filePath)
            self.search_count += 1
        end = datetime.now()
        delt = end - begin
        self.proc_end.set("    用时: " + str(delt.seconds) + "." + str(delt.microseconds // 100000) + " 秒")

    def clear_cache(self):
        self.cache_file = {}
        self.memery_count.set(f"    内存占用: {0}")


if __name__ == '__main__':
    # 历史记录，滚动条 or 固定个数
    # 历史记录删除：单个删除、清除对应记录的内存
    # "搜索路径"的页面布局
    # 检验搜索路径是否存在
    # 如果没有notepad，则初始化时文本展示框
    # 右键label可清除输入框
    Main()
